Recomposition 최적화 - 파라미터의 Stability

#Android #Android_Compose

* Compose phases 에 대해서(특히 Composition phase) 어느정도 알고있다는 가정하에 작성하였습니다.

1. Recomposition의 트리거

Recomposition is typically triggered by a change to a State<T> object. Compose tracks these and runs all composables in the Composition that read that particular State<T>, and any composables that they call that cannot be skipped.

Recomposition은 State 객체에 변경이 발생하면 실행된다.
State 객체에만 변경이 발생하면 Recomposition이 일어날까?

@Composable
fun RecompositionScreen() {
    var count by remember { mutableIntStateOf(0) }

    SideEffect {
        println("Recomposition - ${count}")
    }

    Column {
        Button(
            onClick = {
                count+=1
            },
            content = {
                Text(
                    text = "Click"
                )
            }
        )
    }
}

Recomposition이 발생했다면, println("Recomposition - ${count}") 부분이 출력되어야 한다. 하지만 이 부분은 출력되지 않는다. 그렇다면 Recomposition은 발생하지 않는다는 의미이다. 왜? => count state를 읽는 composable이 없기 때문이다.

결국에 Recomposition이 발생하는 조건은

  1. State 객체가 변경되었을 때
  2. 그리고 그것을 읽는 Composable이 존재할 때
    이다.
@Composable
fun RecompositionScreen() {
    var count by remember { mutableIntStateOf(0) }

    SideEffect {
        println("Recomposition - ${count}")
    }

    Column {
        Button(
            onClick = {
                count+=1
            },
            content = {
                Text(
                    text = "Click: $count" // 이라면 Recomposition이 발생하게된다.
                )
            }
        )
    }
}

1-1. 변경된 State를 읽는 Composable에서만 Recomposition이 발생해?

그렇진 않다.
일단 Recomposition이 발생하면 Recomposition 루트 노드에 이어진 모든 노드들은 recomposition 평가 대상이 된다.

@Composable
fun RecompositionScreen() {
    var info by remember { mutableStateOf(Info("title-2")) }
    var count by remember { mutableIntStateOf(0) }

    SideEffect {
        println("RecompositionScreen Recomposition - ${count}")
    }

    Column {
        RecompositionScreenDetail(info)
        Button(
            onClick = {
                count+=1
            },
            content = {
                Text(
                    text = "Click"
                )
            }
        )
        Text(
            text = "count: $count"
        )
        HorizontalDivider(thickness = 2.dp)
    }
}

@Composable
fun RecompositionScreenDetail(
    info: Info
) {
    SideEffect {
        println("RecompositionScreenDetail Recomposition - ${info.hashCode()}")
    }

    Text(
        text = "RecompositionScreenDetail: ${info.title}"
    )
}

data class Info(
    var title: String
)

위의 코드에서 RecompositionScreenDetail() composable 함수도 recomposition이 된다.
이 메서드는 count를 읽지도 않는데? 이것이 파라미터의 Stability를 신경써야 하는 이유이다.

1-2. Recomposition을 발생시키는 내부요인, 외부요인

State 객체를 가지고있는 Composable은 Recomposition을 발생시키는 '내부요인'을 가지고 있는 것이다. 그리고 그 Composable로부터 파라미터를 통해 해당 State 객체를 넘겨받는 Composable은 Recomposition을 발생시키는 '외부요인'을 가지고 있다.
참고 - Tips. Compose stable, unstable 상태와 자주 사용하는 어노테이션 정리, Nanamare


2. Recomposition을 Skip할 수 있거나, Skip할 수 없거나

외부요인, 즉 파라미터를 가진 Composable이 Skippable하려면 그 파라미터가 어떤 조건을 갖추어야 할까? 기본적인 조건은 안드로이드 문서에 설명이 되어있다.

위의 타입들이 왜 Skippable한지에 대한 이유도 설명해주고 있다. 가장 핵심적인 이유는 이 타입들이 파라미터로 정의된다면 Immutable하기 때문에 그 자체로 Stable하다고 할 수 있다는 것이다.

Compose 컴파일러는 컴파일시에 Compose함수가 Skippable한지에 대해 평가한다. Skippable한지를 평가하는 조건은 타입이 Stable한가? 이며 타입이 Stable하다는 것은 Compose 런타임에서 변화가 일어났을 때에 알아차릴 수 있는가? 이다.

위의 말을 다시 반복하자면, Compose 런타임은 Stable한 타입의 변화를 감지할 수 있기 때문에 변화가 없는경우 굳이 Recomposition을 실행할 필요가 없어진다. 그러나 Stable 하지 않은 타입은 Compose가 변화를 제대로 감지하리라는 보장을 해줄 수 없기 때문에 Recomposition에 대한 책임을 질 수 없어 Recomposition을 할 수 있을 때마다 해주는게 자연스러운 작업이라고 생각할 수 있다.

2-1. 다른 타입은?

primitive가 아니라 reference라면? Interface는?
내가 recomposition을 명확하게 이해하기 위해 시간 할애를 가장 많이 한 부분이였다.

Primitive타입이 아니더라도 Stable을 보장할 수 있다면 Skippable할 수 있다.

Stable을 보장하는 방법은

몇가지 헷갈릴 수 있는 경우를 살펴보자.

List같은 Collection 타입은? val로만 설정해주면 Stable 한 것일까?

아니다. 이것은 인터페이스로 이 변수에 실제로 할당되는 인스턴스는 ImmutableList 일수도, MutableList일수도 있다. 이렇게 stable인지 아닌지 구분할 수 없는 인터페이스는 unstable로 간주된다.

List를 가지고 있는 클래스에 Stable 애노테이션을 추가하면?

아래와 같은 경우 Stable로 간주한다. 그러나 fruits 인스턴스에 add(), remove() 메소드를 호출한다고 하더라도 변화가 감지되지 않는다는 것을 주의해야 한다.

@Stable
data class Info(
  val fruits: List<String>
)

참조형 타입을 가지고있는 참조형 타입은?

아래와 같은 경우로, 가지고 있는 참조형 타입의 변수가 immutable하며 primitive하다. 이 경우에 Compose는 Info 타입을 Stable한 타입으로 간주할 수 있게된다.

Note: All deeply immutable types can safely be considered stable types.
Android Doc - Lifecycle of Composables

data class Info(
	val infoExra: InfoExtra
)

data class InfoExtra(
	val titleExtra: String
)

*그러나 titleExtra 변수가 mutable하게 선언되어 있다거나, 인터페이스 타입의 변수라면 Compose는 이를 Stable한 타입으로 간주할 수 없게 된다.

2-2. Data Class와 General Class의 Stable

Stability를 공부하면서 Data Class와 General Class의 차이를 실감하게 되었다.
그동안은 copy(), toString()등의 메소드 사용과 destructuring를 위해서 data class를 사용하였는데, 이것이 persistent쪽으로도 관련이 있을 수 있다는걸 체감하게 된 것이다.

아래의 두 클래스는 data 키워드가 있냐, 없냐의 차이이다.

data class DataInfo(
  val title: String
)

class Info(
  val title: String
)

편의 메서드와 분해 이외에 제일 중요한게 있다면 equals(), hashcode() 일 것이다.
아래의 테스트를 보면 dataInfo1과 2는 서로 다른 인스턴스임에도 assertEquals를 통과한다.

@Test  
fun dataClass_generalClass_equals() {  
    val dataInfo1 = DataInfo("DataInfo")  
    val dataInfo2 = DataInfo("DataInfo")  
  
    val info1 = Info("Info")  
    val info2 = Info("Info")  
  
    assertEquals(dataInfo1, dataInfo2)  
    assertNotEquals(info1, info2)  
}

이것은 Data Class가 equals()와 hashcode()메서드를 overriding하기 때문인데 자세한 것은 이 포스트의 범위를 넘어가니 참고 포스트로 대신한다. 간단히 말해서 data class는 같은 타입의 인스턴스에 대해 equals를 판단할 때 메모리의 주소가 아니라 객체가 가지고 있는 변수의 값을 비교한다. 그 값이 같다면 hashcode도 같은 값을 리턴한다. (general class는 메모리의 주소로 equals를 판단한다.) 참고 포스트 - 코틀린 data class에서 자동으로 처리하는 equals와 hashCode를 알아보자., Taehwan

그렇다면 이것이 Compose의 Stability랑 어떤 관계가 있을까?
Compose는 타입이 Stable하다면 Recomposition 여부를 체크할 때 equals()메서드의 반환값을 가지고 사용한다. 즉, Data Class로 선언한 타입이라면 새로운 인스턴스를 생성해서 할당해줬음에도 Recomposition이 일어나지 않을 수 있다는 것이다. 반면 Class로 선언한 타입의 경우 같은 값을 가진 인스턴스를 새로 생성해서 할당해줬다면 Recomposition이 일어나게 된다. 이 차이를 알고 사용하는것이 중요하다.

2-2. 타입들에 대한 테스트

내가 헷갈릴만한 거의 모든 타입에 대한 테스트를 해보게 되었다. 이를 정리해본다.

// 1번
data class Info(
  val title: String
)

// 2번
data class Info(
  var title: String
)

// 3번
class Info(
  val title: String
)

// 4번
class Info(
  var title: String
)

// 5번
@Stable
data class Info(
  var title: String
)

// 6번
@Stable
class Info(
  var title: String
)

// 7번
class Info(
  var title: MutableState<String> = mutableStateOf("")
)

// 8번
data class Info(
  val fruits: List<String>
)

// 9번
@Stable
data class Info(
  val fruits: List<String>
)

// 10번
data class Info(
	val infoExra: InfoExtra
)

data class InfoExtra(
	val titleExtra: String
)

/*
- 1번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 2번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 3번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 4번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 5번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 6번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 7번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 8번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 9번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 10번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
*/

3. 참고 자료